Skip to content

fix(dev): proxy dev WebSocket upgrades natively across runtimes#4376

Open
productdevbook wants to merge 6 commits into
nitrojs:mainfrom
productdevbook:fix/dev-websocket-bun
Open

fix(dev): proxy dev WebSocket upgrades natively across runtimes#4376
productdevbook wants to merge 6 commits into
nitrojs:mainfrom
productdevbook:fix/dev-websocket-bun

Conversation

@productdevbook

@productdevbook productdevbook commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Problem

WebSocket upgrades in nitro dev were broken on non-Node runtimes (refs #3939).

  • Under Bun (bun --bun), the dev runtime entry hardcoded crossws/adapters/node, which throws:
    [crossws] Using Node.js adapter in an incompatible environment.
    
  • Even past the crash, the dev server proxied WebSocket upgrades from the parent process to the worker over raw Node sockets (the http.Server "upgrade" event + httpxy). That path is Node-only and silently fails under Bun: Bun's node:http server drops manual upgrade writes, and its node:http client never surfaces the 101 response (oven-sh/bun#10441). The browser just hangs on "Connecting…".

Root cause

The dev server runs the app in a worker and proxies requests from a parent server. WebSocket upgrades were proxied over raw Node sockets:

  • the parent forced the Node adapter (srvx/node) and intercepted upgrades via the http.Server "upgrade" event;
  • the worker's crossws adapter was hardcoded to Node.

Both assumptions break under Bun/Deno, which serve natively (Bun.serve / Deno.serve) and expose no Node upgrade socket.

Fix

Proxy WebSocket upgrades the runtime-native way using crossws' createWebSocketProxy:

  • src/dev/server.ts — serve via srvx (auto-selects the Node/Bun/Deno adapter) and attach a WebSocket reverse-proxy plugin instead of the Node-only "upgrade" event + httpxy path. The worker address is captured from manager.onReady.
  • src/dev/_ws-proxy.ts (new) — the proxy plugin. Resolves the runtime-appropriate crossws server adapter, terminates the client socket with crossws, and bridges to the dev worker with a standard WebSocket client. Waits for the worker to report its address before proxying (an upgrade can arrive right after a reload) and forwards the subprotocol defensively (Deno closes the request after upgradeWebSocket()).
  • src/presets/_nitro/runtime/nitro-dev.ts — expose hooks via the websocket AppEntry field so env-runner attaches the correct crossws adapter per runner, instead of hardcoding the Node one.
  • src/runtime/internal/app.ts — cache resolveWebsocketHooks per request. crossws resolves hooks on every lifecycle event (upgrade/open/message/close) by re-running the app handler; caching avoids re-running the whole pipeline per message and fixes Deno, where the upgrade request is no longer readable after Deno.upgradeWebSocket().

Bumps env-runner to ^0.1.15.

Result

nitro dev WebSocket upgrades work end-to-end (connect + bidirectional messages) under Node, Bun (--bun), and Deno.

Note

vite dev under bun --bun is not covered here: Vite serves via node:http, and Bun's node:http upgrade support is still incomplete in both directions (oven-sh/bun#10441), so the handshake cannot complete through Vite's listener yet. Deno's node:http upgrade does work.

Tests

  • New WebSocket regression test in the nitro-dev preset suite: an echo handler at test/fixture/server/routes/ws.ts, features.websocket enabled on the fixture, and a real WebSocket connection asserting connect + echo in dev mode (fails before, passes after).
  • Manually verified open + bidirectional ping/pong under Node, Bun (--bun), and Deno.
  • Updated the vercel preset snapshot for the new /ws fixture route.
  • nitro-dev / node / bun suites, typecheck, lint, fmt clean.

🤖 Generated with Claude Code

The dev runtime entry hardcoded `crossws/adapters/node`, which throws
`[crossws] Using Node.js adapter in an incompatible environment` when the
dev worker runs under Bun (e.g. `bun --bun vite dev`).

Expose the websocket hooks via the `websocket` AppEntry field so env-runner
attaches the correct crossws adapter per runtime (node/bun/deno) through
`crossws/server`, instead of selecting the Node.js adapter unconditionally.

Closes nitrojs#3939

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@productdevbook productdevbook requested a review from pi0 as a code owner June 23, 2026 15:29
@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown

@productdevbook is attempting to deploy a commit to the Nitro Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33a3d59d-5341-4cea-be4a-34a1db44727b

📥 Commits

Reviewing files that changed from the base of the PR and between 9a8b926 and 8f7ac8f.

📒 Files selected for processing (1)
  • src/dev/server.ts

📝 Walkthrough

Walkthrough

The Nitro dev entry removes hardcoded Node crossws adapter wiring and instead exports a websocket property using resolveWebsocketHooks. The dev server gains runtime-selected WebSocket proxying: a crossws-backed plugin for Bun/Deno and the existing Node upgrade event path. resolveWebsocketHooks is rewritten to cache hooks per request via WeakMap. A fixture /ws route and nitro-dev WebSocket test are added, with Vercel snapshots updated accordingly.

Changes

Nitro dev WebSocket wiring and preset coverage

Layer / File(s) Summary
Dev entry and resolveWebsocketHooks caching
src/presets/_nitro/runtime/nitro-dev.ts, src/runtime/internal/app.ts
nitro-dev.ts removes the Node crossws upgrade handler and exports a websocket property delegating to resolveWebsocketHooks. resolveWebsocketHooks is rewritten to cache computed hooks per ServerRequest in a WeakMap, fetching only once per request.
Dev server WebSocket proxy (Bun/Deno and Node)
src/dev/server.ts, package.json
Imports switch from srvx/node to srvx; a private #workerAddr field stores the runner address on readiness; listen becomes async and conditionally creates a crossws-backed createWebSocketProxyPlugin for Bun/Deno, while Node retains the upgrade event path; upgrade waits for runner readiness before proxying. env-runner bumped to ^0.1.15.
Fixture route, tests, and Vercel snapshots
test/fixture/nitro.config.ts, test/fixture/server/routes/ws.ts, test/presets/nitro-dev.test.ts, test/presets/vercel.test.ts
Fixture enables features.websocket and adds a /ws WebSocket handler. A nitro-dev test opens a WebSocket, sends "ping", and asserts ["connected", "pong"]. Vercel snapshots add a /ws route rule and functions/ws.func entry.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Possibly related PRs

  • nitrojs/nitro#3836: Refactored the dev server upgrade path to delegate to env-runner, which is the same upgrade proxy infrastructure this PR extends with readiness-waiting and Bun/Deno plugin support.
  • nitrojs/nitro#3860: Changes resolveWebsocketHooks import to #runtime/app, directly touching the same function this PR rewrites with WeakMap caching.
  • nitrojs/nitro#3964: Modifies the nitro-dev.ts WebSocket enablement guard (import.meta._websocket), the same flag this PR uses to conditionally export the websocket property.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits and accurately summarizes the WebSocket upgrade fix in dev across runtimes.
Description check ✅ Passed The description is directly related to the WebSocket dev-runtime fix and matches the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/presets/_nitro/runtime/nitro-dev.ts`:
- Around line 24-25: The multi-line comments on lines 24-25 in the nitro-dev.ts
file that explain the crossws adapter behavior are restating what the code does
inline, which violates the repo's style guidelines for src/**/*.{ts,js} files
that specify not to add comments explaining what lines do unless explicitly
prompted. Remove these comment lines entirely to align with the coding
standards.

In `@test/presets/nitro-dev.test.ts`:
- Around line 30-42: The websocket is not being closed in the failure paths when
a timeout or error occurs. In the setTimeout callback that rejects with
"websocket timeout" and in the ws.addEventListener("error") handler, ensure you
call ws.close() before calling reject. This guarantees that the socket is
properly cleaned up in both error and timeout scenarios, preventing dangling
connections and test flakiness.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c857a932-4f46-495d-b375-6351cf973c41

📥 Commits

Reviewing files that changed from the base of the PR and between de9708f and 10b6edd.

📒 Files selected for processing (4)
  • src/presets/_nitro/runtime/nitro-dev.ts
  • test/fixture/nitro.config.ts
  • test/fixture/server/routes/ws.ts
  • test/presets/nitro-dev.test.ts

Comment on lines +24 to +25
// Let the dev runner attach the runtime-appropriate crossws adapter
// (node/bun/deno) via `crossws/server` instead of hardcoding the Node.js one.

@coderabbitai coderabbitai Bot Jun 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove line-explaining comments in runtime entry.

These comments restate behavior inline and should be removed to align with repo style for src/**/*.{ts,js}.

As per coding guidelines, src/**/*.{ts,js} says: "Do not add comments explaining what the line does unless prompted."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/presets/_nitro/runtime/nitro-dev.ts` around lines 24 - 25, The multi-line
comments on lines 24-25 in the nitro-dev.ts file that explain the crossws
adapter behavior are restating what the code does inline, which violates the
repo's style guidelines for src/**/*.{ts,js} files that specify not to add
comments explaining what lines do unless explicitly prompted. Remove these
comment lines entirely to align with the coding standards.

Source: Coding guidelines

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hcharly637-ux Could you clarify what you'd like me to do here? It looks like your message may have been sent by accident. If you'd like, I can:

  • Confirm once the comments on lines 24-25 have been removed, or
  • Explain further why the guideline flags inline comments restating code behavior, or
  • Skip this suggestion if you disagree with it.

Let me know how you'd like to proceed.


🧠 Learnings used
Learnt from: pi0
Repo: nitrojs/nitro PR: 3887
File: src/build/vite/bundler.ts:77-99
Timestamp: 2026-01-21T18:01:10.943Z
Learning: In defu v6, array properties are concatenated rather than overwritten when merging defaults with overrides. Example: defu({ array:['a','b'] }, { array:['c'] }) -> { array:['a','b','c'] }. When reviewing code that uses defu to merge config objects (e.g., src/build/vite/bundler.ts), assume arrays accumulate values from defaults and overrides. This affects plugins/loader configurations and any array-based config. If you need array replacement, consider a different merge strategy or post-process arrays after defu.

Learnt from: Teages
Repo: nitrojs/nitro PR: 4166
File: docs/4.examples/graphql-pothos.md:176-179
Timestamp: 2026-04-05T08:40:20.433Z
Learning: For projects using Pothos GraphQL v4 (`pothos/core` ^4.x), remember that output fields are nullable by default (unlike Pothos v3). The `nullable` option defaults to `true` unless you set `defaultFieldNullability: false` on the `SchemaBuilder` in your schema. During code review, ensure the intended nullability is explicit/consistent: set `nullable: true` explicitly when clarity is important, and use `nullable: false` (or `defaultFieldNullability: false`) when fields must be non-null.

Comment on lines +30 to +42
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("websocket timeout")), 5000);
ws.addEventListener("error", (error) => reject(error as any));
ws.addEventListener("message", (event) => {
messages.push(String(event.data));
if (messages.length === 2) {
clearTimeout(timer);
ws.close();
resolve();
}
});
ws.addEventListener("open", () => ws.send("ping"));
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Close socket on timeout/error to avoid dangling test resources.

Failure paths currently reject without guaranteed socket cleanup, which can leave open handles and make the suite flaky.

Suggested fix
           await new Promise<void>((resolve, reject) => {
-            const timer = setTimeout(() => reject(new Error("websocket timeout")), 5000);
-            ws.addEventListener("error", (error) => reject(error as any));
+            const fail = (error: unknown) => {
+              clearTimeout(timer);
+              ws.close();
+              reject(error);
+            };
+            const timer = setTimeout(() => fail(new Error("websocket timeout")), 5000);
+            ws.addEventListener("error", (error) => fail(error as any), { once: true });
             ws.addEventListener("message", (event) => {
               messages.push(String(event.data));
               if (messages.length === 2) {
                 clearTimeout(timer);
                 ws.close();
                 resolve();
               }
             });
             ws.addEventListener("open", () => ws.send("ping"));
           });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("websocket timeout")), 5000);
ws.addEventListener("error", (error) => reject(error as any));
ws.addEventListener("message", (event) => {
messages.push(String(event.data));
if (messages.length === 2) {
clearTimeout(timer);
ws.close();
resolve();
}
});
ws.addEventListener("open", () => ws.send("ping"));
});
await new Promise<void>((resolve, reject) => {
const fail = (error: unknown) => {
clearTimeout(timer);
ws.close();
reject(error);
};
const timer = setTimeout(() => fail(new Error("websocket timeout")), 5000);
ws.addEventListener("error", (error) => fail(error as any), { once: true });
ws.addEventListener("message", (event) => {
messages.push(String(event.data));
if (messages.length === 2) {
clearTimeout(timer);
ws.close();
resolve();
}
});
ws.addEventListener("open", () => ws.send("ping"));
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/presets/nitro-dev.test.ts` around lines 30 - 42, The websocket is not
being closed in the failure paths when a timeout or error occurs. In the
setTimeout callback that rejects with "websocket timeout" and in the
ws.addEventListener("error") handler, ensure you call ws.close() before calling
reject. This guarantees that the socket is properly cleaned up in both error and
timeout scenarios, preventing dangling connections and test flakiness.

@pkg-pr-new

pkg-pr-new Bot commented Jun 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/nitro@4376

commit: 8f7ac8f

@socket-security

socket-security Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedenv-runner@​0.1.15761001009580

View full report

@socket-security

socket-security Bot commented Jun 30, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm env-runner is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: package.jsonnpm/env-runner@0.1.15

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/env-runner@0.1.15. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@pi0 pi0 changed the title fix(dev): use runtime-agnostic websocket adapter for dev runner fix(dev): proxy dev WebSocket upgrades natively across runtimes Jun 30, 2026
@pi0 pi0 self-assigned this Jun 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/dev/server.ts (1)

74-88: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear stale worker addresses before proxying upgrades.

#workerAddr is set on ready but never cleared when the runner closes or reloads. Since _ws-proxy.ts only waits while no port is available, WebSocket upgrades during a reload/crash can be proxied to a dead previous worker instead of waiting for the new one.

🐛 Proposed fix
     this.#manager.onClose((_runner, cause) => {
+      this.#workerAddr = undefined;
       this.#workerError = cause;
       if (this.#workerRetries++ < 3) {
         this.nitro.logger.info("Restarting dev worker...", cause ? `Cause: ${cause}` : "");
         this.reload();
       } else {
     nitro.hooks.hook("dev:start", () => {
       this.#building = true;
+      this.#workerAddr = undefined;
       this.#buildError = undefined;
     });
   async `#reload`() {
+    this.#workerAddr = undefined;
     const runnerName =
       this.nitro.options.devServer.runner || process.env.NITRO_DEV_RUNNER || "node-worker";

Also applies to: 137-142

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/server.ts` around lines 74 - 88, Clear the stale worker address
whenever the runner closes so WebSocket upgrades don’t keep using a dead
backend. In `Server`’s `onClose` handler, reset `#workerAddr` (and any related
readiness state if needed) before calling `reload()`, matching the logic that
sets it in `onReady`. Make sure `_ws-proxy.ts` will observe no available worker
address during restart/crash and wait for the next `onReady` update instead of
proxying to the old address.
🧹 Nitpick comments (2)
src/runtime/internal/app.ts (1)

61-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the internal cache below the exported API.

websocketHooksCache is a non-exported helper and should live after the exported function.

♻️ Proposed refactor
-const websocketHooksCache = new WeakMap<object, Promise<Partial<WebSocketHooks>>>();
-
 export function resolveWebsocketHooks(req: ServerRequest): Promise<Partial<WebSocketHooks>> {
   let hooks = websocketHooksCache.get(req);
   if (!hooks) {
     hooks = serverFetch(req).then((res) => ((res as any).crossws as Partial<WebSocketHooks>) || {});
     websocketHooksCache.set(req, hooks);
   }
   return hooks;
 }
+
+const websocketHooksCache = new WeakMap<object, Promise<Partial<WebSocketHooks>>>();

As per coding guidelines, "Place non-exported/internal helpers at the end of the file."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/internal/app.ts` around lines 61 - 69, The non-exported helper
websocketHooksCache is declared before the exported resolveWebsocketHooks API,
which conflicts with the internal-helper ordering guideline. Move
websocketHooksCache to below resolveWebsocketHooks in app.ts, keeping the
exported function first and leaving its usage unchanged so the WeakMap cache
still supports resolveWebsocketHooks.

Source: Coding guidelines

src/dev/_ws-proxy.ts (1)

4-5: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move internal type aliases to the end of the file.

CrosswsPlugin and SrvxPlugin are internal helpers; TypeScript can resolve them after the exported function.

♻️ Proposed refactor
-type CrosswsPlugin = Awaited<typeof import("crossws/server/bun")>["plugin"];
-type SrvxPlugin = ReturnType<CrosswsPlugin>;
-
 /**
  * Create a runtime-native WebSocket reverse-proxy plugin for the dev server.
   return plugin({ resolve: () => hooks });
 }
+
+type CrosswsPlugin = Awaited<typeof import("crossws/server/bun")>["plugin"];
+type SrvxPlugin = ReturnType<CrosswsPlugin>;

As per coding guidelines, "Place non-exported/internal helpers at the end of the file."

Also applies to: 65-66

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/_ws-proxy.ts` around lines 4 - 5, Move the internal type aliases
CrosswsPlugin and SrvxPlugin to the end of _ws-proxy.ts, after the exported
function and other public symbols. Keep the exported API (including the
proxy-related function) at the top, and place these non-exported helper types at
the bottom so the file follows the internal-helpers-last guideline.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/dev/_ws-proxy.ts`:
- Around line 27-33: The WebSocket target builder in _ws-proxy’s target callback
should normalize IPv6 hosts before interpolating them into the ws:// URL. Update
the logic around getAddress(), addr.host, and the returned URL so that IPv6
values are wrapped in brackets while preserving existing behavior for IPv4 and
the 127.0.0.1 fallback, ensuring the generated target string remains a valid
WebSocket URL.

---

Outside diff comments:
In `@src/dev/server.ts`:
- Around line 74-88: Clear the stale worker address whenever the runner closes
so WebSocket upgrades don’t keep using a dead backend. In `Server`’s `onClose`
handler, reset `#workerAddr` (and any related readiness state if needed) before
calling `reload()`, matching the logic that sets it in `onReady`. Make sure
`_ws-proxy.ts` will observe no available worker address during restart/crash and
wait for the next `onReady` update instead of proxying to the old address.

---

Nitpick comments:
In `@src/dev/_ws-proxy.ts`:
- Around line 4-5: Move the internal type aliases CrosswsPlugin and SrvxPlugin
to the end of _ws-proxy.ts, after the exported function and other public
symbols. Keep the exported API (including the proxy-related function) at the
top, and place these non-exported helper types at the bottom so the file follows
the internal-helpers-last guideline.

In `@src/runtime/internal/app.ts`:
- Around line 61-69: The non-exported helper websocketHooksCache is declared
before the exported resolveWebsocketHooks API, which conflicts with the
internal-helper ordering guideline. Move websocketHooksCache to below
resolveWebsocketHooks in app.ts, keeping the exported function first and leaving
its usage unchanged so the WeakMap cache still supports resolveWebsocketHooks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 05c6b30f-6849-412d-9728-39de2cee2b2a

📥 Commits

Reviewing files that changed from the base of the PR and between 231d7f6 and 9a8b926.

📒 Files selected for processing (3)
  • src/dev/_ws-proxy.ts
  • src/dev/server.ts
  • src/runtime/internal/app.ts

Comment thread src/dev/_ws-proxy.ts Outdated
Comment on lines +27 to +33
target: (peer) => {
const addr = getAddress();
if (!addr?.port) {
throw new Error("Dev worker is not ready");
}
const { pathname, search } = new URL(peer.request.url);
return `ws://${addr.host || "127.0.0.1"}:${addr.port}${pathname}${search}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize IPv6 hosts before building the WebSocket URL.

If addr.host is IPv6, the interpolated URL becomes invalid unless the host is bracketed.

🐛 Proposed fix
       const { pathname, search } = new URL(peer.request.url);
-      return `ws://${addr.host || "127.0.0.1"}:${addr.port}${pathname}${search}`;
+      const host = addr.host || "127.0.0.1";
+      const hostname = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+      return `ws://${hostname}:${addr.port}${pathname}${search}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
target: (peer) => {
const addr = getAddress();
if (!addr?.port) {
throw new Error("Dev worker is not ready");
}
const { pathname, search } = new URL(peer.request.url);
return `ws://${addr.host || "127.0.0.1"}:${addr.port}${pathname}${search}`;
target: (peer) => {
const addr = getAddress();
if (!addr?.port) {
throw new Error("Dev worker is not ready");
}
const { pathname, search } = new URL(peer.request.url);
const host = addr.host || "127.0.0.1";
const hostname = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
return `ws://${hostname}:${addr.port}${pathname}${search}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/_ws-proxy.ts` around lines 27 - 33, The WebSocket target builder in
_ws-proxy’s target callback should normalize IPv6 hosts before interpolating
them into the ws:// URL. Update the logic around getAddress(), addr.host, and
the returned URL so that IPv6 values are wrapped in brackets while preserving
existing behavior for IPv4 and the 127.0.0.1 fallback, ensuring the generated
target string remains a valid WebSocket URL.

…n bun/deno

Node proxies upgrades over the `http.Server` "upgrade" event + raw socket
(httpxy) directly — transparent and single-framed. The crossws WebSocket
bridge is only needed on Bun/Deno, which serve natively and expose no Node
upgrade socket. Inline the proxy helper into the dev server as an internal util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@pi0x pi0x force-pushed the fix/dev-websocket-bun branch from 71630d5 to 8f7ac8f Compare June 30, 2026 18:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/dev/server.ts (1)

40-40: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid proxying Bun/Deno upgrades to a stale worker address.

#workerAddr is only set on readiness, while the native proxy waits for any cached port. After a reload, the old address can satisfy that check and route new WebSocket upgrades to the previous worker.

Proposed fix
-      plugins.push(await createWebSocketProxyPlugin(() => this.#workerAddr));
+      plugins.push(
+        await createWebSocketProxyPlugin(() =>
+          this.#manager.ready ? this.#workerAddr : undefined
+        )
+      );

Also applies to: 79-79, 160-162, 329-333

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/server.ts` at line 40, The stale-worker routing issue is in Server’s
native proxy flow around `#workerAddr`, where cached readiness state can still
satisfy upgrade handling after a reload. Update the worker selection logic in
Server (including the readiness/proxy path and any WebSocket upgrade handling)
so Bun/Deno upgrades only use the currently active worker address, and clear or
validate `#workerAddr` when a reload changes workers to prevent routing to the
previous instance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/dev/server.ts`:
- Around line 133-145: The upgrade path can reject asynchronously, but the Node
upgrade event handler is not awaiting or catching those failures, which can
leave sockets open and surface unhandled rejections. Update the server’s
`upgrade` flow by handling errors from `this.upgrade(...)` in the
`server.on("upgrade")` callback (and the other duplicated call site noted in the
diff), so `HTTPError` and proxy failures are caught, the socket is closed or
destroyed on failure, and the existing `upgrade` method remains the single place
that performs the proxy attempt.
- Around line 142-145: The bounded readiness waits in the server proxy flow can
time out and still call this.#manager.upgrade while the worker is unavailable;
update the upgrade handling path(s) in Server to fail explicitly once the wait
expires instead of continuing. Add a contextual error that names the operation
and the unavailable worker/manager state, and return or throw that failure
before delegating to this.#manager.upgrade in both affected wait loops.

---

Outside diff comments:
In `@src/dev/server.ts`:
- Line 40: The stale-worker routing issue is in Server’s native proxy flow
around `#workerAddr`, where cached readiness state can still satisfy upgrade
handling after a reload. Update the worker selection logic in Server (including
the readiness/proxy path and any WebSocket upgrade handling) so Bun/Deno
upgrades only use the currently active worker address, and clear or validate
`#workerAddr` when a reload changes workers to prevent routing to the previous
instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33a3d59d-5341-4cea-be4a-34a1db44727b

📥 Commits

Reviewing files that changed from the base of the PR and between 9a8b926 and 8f7ac8f.

📒 Files selected for processing (1)
  • src/dev/server.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/dev/server.ts (1)

40-40: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid proxying Bun/Deno upgrades to a stale worker address.

#workerAddr is only set on readiness, while the native proxy waits for any cached port. After a reload, the old address can satisfy that check and route new WebSocket upgrades to the previous worker.

Proposed fix
-      plugins.push(await createWebSocketProxyPlugin(() => this.#workerAddr));
+      plugins.push(
+        await createWebSocketProxyPlugin(() =>
+          this.#manager.ready ? this.#workerAddr : undefined
+        )
+      );

Also applies to: 79-79, 160-162, 329-333

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/server.ts` at line 40, The stale-worker routing issue is in Server’s
native proxy flow around `#workerAddr`, where cached readiness state can still
satisfy upgrade handling after a reload. Update the worker selection logic in
Server (including the readiness/proxy path and any WebSocket upgrade handling)
so Bun/Deno upgrades only use the currently active worker address, and clear or
validate `#workerAddr` when a reload changes workers to prevent routing to the
previous instance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/dev/server.ts`:
- Around line 133-145: The upgrade path can reject asynchronously, but the Node
upgrade event handler is not awaiting or catching those failures, which can
leave sockets open and surface unhandled rejections. Update the server’s
`upgrade` flow by handling errors from `this.upgrade(...)` in the
`server.on("upgrade")` callback (and the other duplicated call site noted in the
diff), so `HTTPError` and proxy failures are caught, the socket is closed or
destroyed on failure, and the existing `upgrade` method remains the single place
that performs the proxy attempt.
- Around line 142-145: The bounded readiness waits in the server proxy flow can
time out and still call this.#manager.upgrade while the worker is unavailable;
update the upgrade handling path(s) in Server to fail explicitly once the wait
expires instead of continuing. Add a contextual error that names the operation
and the unavailable worker/manager state, and return or throw that failure
before delegating to this.#manager.upgrade in both affected wait loops.

---

Outside diff comments:
In `@src/dev/server.ts`:
- Line 40: The stale-worker routing issue is in Server’s native proxy flow
around `#workerAddr`, where cached readiness state can still satisfy upgrade
handling after a reload. Update the worker selection logic in Server (including
the readiness/proxy path and any WebSocket upgrade handling) so Bun/Deno
upgrades only use the currently active worker address, and clear or validate
`#workerAddr` when a reload changes workers to prevent routing to the previous
instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33a3d59d-5341-4cea-be4a-34a1db44727b

📥 Commits

Reviewing files that changed from the base of the PR and between 9a8b926 and 8f7ac8f.

📒 Files selected for processing (1)
  • src/dev/server.ts
🛑 Comments failed to post (2)
src/dev/server.ts (2)

133-145: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Catch async upgrade failures from the Node upgrade event.

server.on("upgrade") does not await this.upgrade(...); rejected HTTPErrors or proxy failures become unhandled and can leave the socket open.

Proposed fix
-      server.node.server.on("upgrade", (req, sock, head) => this.upgrade(req, sock, head));
+      server.node.server.on("upgrade", (req, sock, head) => {
+        void this.upgrade(req, sock, head).catch((error) => {
+          this.nitro.logger.warn(
+            `Failed to proxy WebSocket upgrade: ${
+              error instanceof Error ? error.message : String(error)
+            }`
+          );
+          sock.destroy();
+        });
+      });

Also applies to: 172-174

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/server.ts` around lines 133 - 145, The upgrade path can reject
asynchronously, but the Node upgrade event handler is not awaiting or catching
those failures, which can leave sockets open and surface unhandled rejections.
Update the server’s `upgrade` flow by handling errors from `this.upgrade(...)`
in the `server.on("upgrade")` callback (and the other duplicated call site noted
in the diff), so `HTTPError` and proxy failures are caught, the socket is closed
or destroyed on failure, and the existing `upgrade` method remains the single
place that performs the proxy attempt.

142-145: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fail explicitly when the readiness wait expires.

Both bounded waits can fall through and continue proxying while the worker is still unavailable. Return a contextual failure before delegating. As per coding guidelines: “Prefer explicit errors over silent failures” and “Include actionable context in error messages.”

Proposed fix
     for (let i = 0; i < 200 && !this.#manager.ready; i++) {
       await new Promise((r) => setTimeout(r, 50));
     }
+    if (!this.#manager.ready) {
+      throw new HTTPError({
+        status: 503,
+        statusText: "Dev worker was not ready after 10s; cannot proxy WebSocket upgrade.",
+      });
+    }
     return this.#manager.upgrade({ node: { req, socket, head } });
     async upgrade(request: Request) {
       for (let i = 0; i < 200 && !getAddress()?.port; i++) {
         await new Promise((r) => setTimeout(r, 50));
       }
+      if (!getAddress()?.port) {
+        throw new Error("Dev worker did not report an address after 10s; cannot proxy WebSocket upgrade.");
+      }
       return proxy.upgrade?.(request);
     },

Also applies to: 329-333

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev/server.ts` around lines 142 - 145, The bounded readiness waits in the
server proxy flow can time out and still call this.#manager.upgrade while the
worker is unavailable; update the upgrade handling path(s) in Server to fail
explicitly once the wait expires instead of continuing. Add a contextual error
that names the operation and the unavailable worker/manager state, and return or
throw that failure before delegating to this.#manager.upgrade in both affected
wait loops.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants